Documentation Index
Fetch the complete documentation index at: https://mintlify.com/phoenixframework/phoenix_live_view/llms.txt
Use this file to discover all available pages before exploring further.
LiveView’s change tracking is a sophisticated mechanism that minimizes the data sent over the wire by tracking which assigns have changed and only re-rendering the affected dynamic parts of your templates.
How It Works
When you first render a template, LiveView sends both static and dynamic parts to the client. On subsequent renders, only changed dynamic parts are sent.
Initial Render
Consider this template:
<h1>{expand_title(@title)}</h1>
This has:
- Static parts:
<h1> and </h1>
- Dynamic parts:
expand_title(@title)
On initial render, all parts are sent to the client.
Subsequent Renders
If @title doesn’t change, nothing is sent. The dynamic part is not even executed.
If @title changes, only the new result of expand_title(@title) is sent.
The Rendered Struct
From lib/phoenix_live_view/engine.ex:100-126:
defmodule Phoenix.LiveView.Rendered do
defstruct [:static, :dynamic, :fingerprint, :root, caller: :not_available]
@type t :: %__MODULE__{
static: [String.t()],
dynamic: (boolean() -> [dyn()]),
fingerprint: integer(),
root: nil | true | false
}
end
:static: List of literal strings (optimized by compiler)
:dynamic: Function that returns dynamic content when called with track_changes? boolean
:fingerprint: Unique identifier for the template
:root: Whether this is a root template
Fingerprints
Fingerprints identify templates uniquely. From lib/phoenix_live_view/engine.ex:1323-1334:
defp fingerprint(block, static) do
<<fingerprint::8*16>> =
[block | static]
|> :erlang.term_to_binary()
|> :erlang.md5()
fingerprint
end
If fingerprints differ between renders, LiveView knows the template changed and disables change tracking for that render.
Assign-Level Tracking
Change tracking works at the assign level:
<div id={"user_#{@user.id}">
{@user.name}
</div>
If @user.name changes but @user.id doesn’t:
@user.name is re-rendered and sent
@user.id is not executed or sent
The __changed__ Map
From lib/phoenix_live_view/engine.ex:392-398:
changed =
quote generated: true do
case unquote(@assigns_var) do
%{__changed__: changed} when track_changes? -> changed
_ -> nil
end
end
The __changed__ map tracks which assigns have been modified:
# When you call assign/3
assign(socket, :count, 5)
# Internally sets
socket.assigns.__changed__ = %{count: true}
Checking for Changes
From lib/phoenix_live_view/engine.ex:1395-1401:
def changed_assign?(changed, name) do
case changed do
%{^name => _} -> true
%{} -> false
nil -> true # No tracking = assume changed
end
end
Nested Field Tracking
LiveView tracks changes through nested map/struct fields:
<div>{@user.profile.bio}</div>
From lib/phoenix_live_view/engine.ex:1412-1428:
def nested_changed_assign?(tail, head, assigns, changed),
do: nested_changed_assign(tail, head, assigns, changed) != false
defp nested_changed_assign(tail, head, assigns, changed) do
case changed do
%{^head => changed} ->
case assigns do
%{^head => assigns} -> recur_changed_assign(tail, assigns, changed)
%{} -> true
end
%{} -> false
nil -> true
end
end
This allows efficient tracking of deeply nested structures.
Function Components
Function components participate in change tracking:
<.show_name name={@user.name} />
Only if @user.name changes will the component re-render.
Explicit Attributes
From lib/phoenix_live_view/engine.ex:750-829:
defp to_component_tracking(meta, fun, expr, extra, vars, caller) do
# Separate static and dynamic parts
{static, dynamic} = case expr do
{{:., _, [{:__aliases__, _, [:Map]}, :merge]}, _, [dynamic, {:%{}, _, static}]} ->
{static, dynamic}
{:%{}, _, static} ->
{static, %{}}
static ->
{static, %{}}
end
# ...
end
Always prefer explicit attributes over spreading assigns:
# Good: Change tracking works
<.card title={@title} class={@title_class} />
# Bad: Disables change tracking
<.card {assigns} />
Common Pitfalls
Variables in Templates
Variables disable change tracking:
<!-- BAD: Disables tracking -->
<% some_var = @x + @y %>
{some_var}
<!-- GOOD: Tracking works -->
{sum(@x, @y)}
From lib/phoenix_live_view/engine.ex:1292-1321:
defp maybe_warn_taint(name, meta, caller) do
if caller && Macro.Env.has_var?(caller, {name, nil}) do
message = """
you are accessing the variable "#{name}" inside a LiveView template.
Using variables in HEEx templates are discouraged as they disable change tracking.
"""
IO.warn(message, Macro.Env.stacktrace(%{caller | line: line}))
end
end
Never define variables in templates or at the top of render/1:# BAD
def render(assigns) do
sum = assigns.x + assigns.y # Disables tracking!
~H"""
{sum}
"""
end
# GOOD
def render(assigns) do
assigns = assign(assigns, :sum, assigns.x + assigns.y)
~H"""
{@sum}
"""
end
Accessing assigns Directly
Never access the assigns variable in templates:
<!-- BAD: Disables tracking -->
<.card_header {assigns} />
<.card_body {assigns} />
<!-- GOOD: Tracking enabled -->
<.card_header title={@title} class={@title_class} />
<.card_body>{render_slot(@inner_block)}</.card_body>
Exception: When calling components as functions:
def card(assigns) do
~H"""
<div class="card">
{card_header(assigns)}
{card_body(assigns)}
</div>
"""
end
Using Map Functions
Never use Map.put/3 or Map.merge/2 on assigns:
# BAD: Breaks change tracking
def card(assigns) do
assigns = Map.put(assigns, :sum, Enum.sum(assigns.values))
~H"""
<p>{@sum}</p>
"""
end
# GOOD: Change tracking works
def card(assigns) do
assigns = assign(assigns, :sum, Enum.sum(assigns.values))
~H"""
<p>{@sum}</p>
"""
end
From the guides (guides/server/assigns-eex.md:243-269):
If you modify the assigns variable with Map.put/3, those assigns inside your HEEx template will not update after the initial render.
Comprehensions
LiveView optimizes comprehensions to track individual items:
<section :for={post <- @posts} :key={post.id}>
<h1>{expand_title(post.title)}</h1>
</section>
Without Keys
By default, index-based tracking is used. Inserting at the beginning causes all items to be re-sent.
With Keys
Using :key enables ID-based tracking:
<section :for={post <- @posts} :key={post.id}>
<h1>{post.title}</h1>
</section>
Now only changed posts are sent, regardless of position.
Memory Trade-offs
From the guides (guides/server/assigns-eex.md:308-313):
To track changes in comprehensions, LiveView needs to perform additional bookkeeping, which requires extra memory on the server. If memory usage is a concern, you should also consider using Phoenix.LiveView.stream/4, which allows you to manage collections without keeping them in memory.
The Diff Algorithm
From lib/phoenix_live_view/diff.ex:134-162:
def render(socket, %Rendered{} = rendered, prints, components) do
{diff, prints, pending, components, template} =
traverse(rendered, prints, %{}, components, {%{}, %{}}, true)
{cid_to_component, _, _} = components
{cdiffs, components} =
render_pending_components(socket, pending, cid_to_component, %{}, components)
diff =
diff
|> maybe_add_template(template)
|> maybe_put_title(socket)
{diff, cdiffs} = extract_events({diff, cdiffs})
{maybe_put_cdiffs(diff, cdiffs), prints, components}
end
The traverse/6 function walks the rendered tree and builds a minimal diff.
Conditional Rendering
From lib/phoenix_live_view/engine.ex:665-687:
defp to_conditional_var(keys, var, live_struct) when keys == %{} do
quote generated: true do
unquote(var) =
case changed do
%{} -> nil
_ -> unquote(live_struct)
end
end
end
defp to_conditional_var(keys, var, live_struct) do
quote do
unquote(var) =
case unquote(changed_assigns(keys)) do
true -> unquote(live_struct)
false -> nil
end
end
end
If no tracked assigns changed, nil is returned, signaling “no change” to the diff engine.
Wire Efficiency
Proper change tracking reduces payload sizes by 90%+ for typical updates:
// Without tracking: Send entire template
{"0": "<div>...</div>"}
// With tracking: Send only changed field
{"0": {"0": "new value"}}
CPU Usage
Change tracking adds minimal CPU overhead:
- Template compilation: One-time cost, generates efficient bytecode
- Runtime checks: Simple map lookups in
__changed__
- Diff generation: Only processes changed parts
Memory Usage
The __changed__ map is small (typically less than 10 keys) and cleared after each render.
Best Practices
Always:
- Use
assign/3 to set assigns
- Access assigns with
@name syntax
- Use explicit attributes for components
- Add
:key to comprehensions when order changes
Never:
- Define variables in templates
- Use
Map.put/3 on assigns
- Spread
assigns to components (except as function calls)
- Access assigns outside
@ syntax
Debugging Change Tracking
Enable debug logging:
require Logger
def handle_event("update", _params, socket) do
Logger.debug("Changed: #{inspect(socket.assigns.__changed__)}")
{:noreply, assign(socket, :count, socket.assigns.count + 1)}
end
Inspect rendered output:
rendered = Phoenix.LiveView.Renderer.to_rendered(socket, MyLive)
IO.inspect(rendered, label: "Rendered")
Summary
Change tracking is automatic and transparent when you follow best practices:
- Use assigns for all dynamic data
- Avoid variables in templates
- Use LiveView functions (
assign/3, update/3) to modify assigns
- Be explicit with component attributes
These simple rules enable LiveView to send minimal diffs and provide exceptional performance.